Skip to content

Fix DurationFormatUtils.formatPeriod() calculation when pattern omits 'M' - #1780

Merged
garydgregory merged 1 commit into
apache:masterfrom
Alwaysgaurav1:fix/duration-format-period-without-months
Sep 3, 2026
Merged

Fix DurationFormatUtils.formatPeriod() calculation when pattern omits 'M'#1780
garydgregory merged 1 commit into
apache:masterfrom
Alwaysgaurav1:fix/duration-format-period-without-months

Conversation

@Alwaysgaurav1

@Alwaysgaurav1 Alwaysgaurav1 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Description

Thanks for your contribution to Apache Commons! Your help is appreciated!

Before you push a pull request, review this list:

  • Read the contribution guidelines for this project.
  • Run the default Maven build goal (mvn) before submitting your pull request to ensure all build checks and tests pass.
  • Write JUnit tests for any changes and ensure they do not break the existing tests.
  • Include a meaningful description of the PR and reference any related JIRA issues.

Fixes a calculation bug in DurationFormatUtils.formatPeriod(long, long, String, boolean, TimeZone) where duration formatting with patterns containing year (y) and day (d) tokens without month (M) tokens (e.g. "y' years 'd' days'" or "y'y 'd'd'") incorrectly inflates the duration by +1 full year (+365 days) when spanning across a calendar year boundary that is less than a full 12-month anniversary.

Problem / Reproduction

Prior to this fix:

  • 31 days (2024-12-15 to 2025-01-15) was formatted as "1 years 31 days" (396 days instead of 31 days).
  • 361 days (2024-01-15 to 2025-01-10) was formatted as "1 years 26 days" (392 days instead of 361 days).
  • 365 days (2024-02-29 to 2025-02-28) was formatted as "1 years 28 days" (393 days instead of 365 days).

Root Cause

  1. years was initialized as end.get(Calendar.YEAR) - start.get(Calendar.YEAR).
  2. When M was not present in tokens, the code rolled month differences into days, but did not decrement years when less than a full 12-month calendar year had elapsed.
  3. start was not advanced to match the subtracted years, which caused subsequent month-to-day accumulations to miscount intervening days across year boundaries.

Solution

  1. When M is omitted and y is present:
    • Check if a full calendar year has elapsed (months < 0 || (months == 0 && days < 0)). If not, decrement years.
    • Advance start by the elapsed years (start.add(Calendar.YEAR, (int) years)).
  2. Roll all remaining intervening months into days until start.get(YEAR) == end.get(YEAR) && start.get(MONTH) == end.get(MONTH).
  3. Borrow any negative remaining days from start.getActualMaximum(Calendar.DAY_OF_MONTH).

Tests Added & Verification

  • Added testFormatPeriodWithoutMonths() to DurationFormatUtilsTest.java verifying:
    • Cross-year durations (< 1 year).
    • Periods near 1 full year.
    • Leap-year to non-leap-year boundaries (e.g. Feb 29 to Feb 28).
  • Ran full test suite via Maven: all 46/46 tests passed with 0 failures and 0 regressions.

@garydgregory

Copy link
Copy Markdown
Member

@Alwaysgaurav1
You didn't follow instructions in the PR template, therefore this PR breaks the build. Don't push until the build passes locally.

@Alwaysgaurav1
Alwaysgaurav1 force-pushed the fix/duration-format-period-without-months branch from 8b50bf4 to 35ada86 Compare August 31, 2026 04:35
@Alwaysgaurav1

Copy link
Copy Markdown
Contributor Author

@garydgregory Apologies for the oversight. I've fixed the Checkstyle issues and verified the build passes locally with mvn test checkstyle:check (0 Checkstyle violations, 47/47 tests passing). Thanks for the reminder!

@garydgregory

Copy link
Copy Markdown
Member

There is one case this PR breaks that I just added to demonstrate the issue:

    @Test
    void testFormatPeriodWithoutMonthsAfterLeapDayAnniversary() {
        final TimeZone timeZone = TimeZone.getTimeZone("UTC");
        final Calendar start = Calendar.getInstance(timeZone);
        start.clear();
        // 2020 was not a leap year
        start.set(2020, Calendar.FEBRUARY, 29);
        final Calendar end = Calendar.getInstance(timeZone);
        end.clear();
        // 2021 was not a leap year
        end.set(2021, Calendar.MARCH, 1);
        assertEquals("1 years 1 days", DurationFormatUtils.formatPeriod(start.getTimeInMillis(), end.getTimeInMillis(), "y' years 'd' days'", false, timeZone));
    }

There is no standard for normalizing dates in non-leap years.
The main branch code normalizes Feb 29 2020 to Feb 28 2020, to stay in the same month.
The PR reverses that to normalize Feb 29 to March 1 to keep chronological order.

I'm not sure if switching the direction will have unintended consequence.

WDYT?

Or, is it possible for the PR to keep the current normalization?

@Alwaysgaurav1

Copy link
Copy Markdown
Contributor Author

Thanks for catching this edge case, @garydgregory!

Root Cause

The issue occurred because start.add(Calendar.YEAR, 1) clamped 2020-02-29 to 2021-02-28. Since the initial calculation for days had already subtracted 29 (1 - 29 = -28), adding the 28 days of February 2021 yielded -28 + 28 = 0 days instead of 1 day.

Solution

We don't need to change the normalization direction. Instead, we can unify the calculation with the rest of the token cascading hierarchy (y -> M -> d -> H -> m -> s -> S):

  1. Apply standard hierarchical borrowing for days and months first (identical to when M is present).
  2. If y is omitted, roll years into months (months += 12 * years; years = 0;).
  3. If M is omitted, roll remaining months into days:
    if (!Token.containsTokenWithValue(tokens, M)) {
        while (months > 0) {
            days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
            months -= 1;
            start.add(Calendar.MONTH, 1);
        }
    }

@garydgregory

Copy link
Copy Markdown
Member

@Alwaysgaurav1
Please rebate on git master to pick up the new test.

@Alwaysgaurav1
Alwaysgaurav1 force-pushed the fix/duration-format-period-without-months branch from 35ada86 to 2e5b6cb Compare September 2, 2026 15:27
@Alwaysgaurav1

Copy link
Copy Markdown
Contributor Author

@garydgregory Rebased on master and updated the fix to unify the token cascading hierarchy.

Root Cause of the Test Failure

In the previous commit, start.add(Calendar.YEAR, 1) clamped 2020-02-29 to 2021-02-28. Because initial days subtracted 29 (1 - 29 = -28), adding the 28 days of February 2021 resulted in -28 + 28 = 0 days instead of 1 day.

Solution

We keep the exact same normalization behavior by unifying how omitted tokens are cascaded (y -> M -> d -> H -> m -> s -> S):

  1. Apply standard hierarchical borrowing for days and months first (same as when M is present).
  2. If y is omitted, roll years into months (months += 12 * years; years = 0;).
  3. If M is omitted, roll remaining months into days:
    if (!Token.containsTokenWithValue(tokens, M)) {
        while (months > 0) {
            days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
            months -= 1;
            start.add(Calendar.MONTH, 1);
        }
    }

@garydgregory
garydgregory merged commit e438090 into apache:master Sep 3, 2026
23 of 24 checks passed
@garydgregory

Copy link
Copy Markdown
Member

Thanks @Alwaysgaurav1 , merged 🚀

garydgregory added a commit that referenced this pull request Sep 3, 2026
'M' (#1780).

- Fix inline comment.
- Sort members.
- Remove extra blank line at EOF.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants